Опануйте керування змінними в межах запиту в Node.js за допомогою AsyncLocalStorage. Позбудьтеся прокидання пропсів і створюйте чистіші, більш спостережувані додатки для глобальної аудиторії.
Розкриття асинхронного контексту в JavaScript: Глибоке занурення в управління змінними в межах запиту
У світі сучасної серверної розробки управління станом є фундаментальною проблемою. Для розробників, що працюють з Node.js, ця проблема посилюється його однопотоковою, неблокуючою, асинхронною природою. Хоча ця модель неймовірно потужна для створення високопродуктивних, орієнтованих на введення-виведення додатків, вона створює унікальну проблему: як підтримувати контекст для конкретного запиту, коли він проходить через різні асинхронні операції, від middleware до запитів до бази даних і викликів сторонніх API? Як гарантувати, що дані з запиту одного користувача не потраплять до іншого?
Роками спільнота JavaScript боролася з цим, часто вдаючись до громіздких патернів, таких як "прокидання пропсів" (prop drilling) — передача специфічних для запиту даних, як-от ID користувача або ID трасування, через кожну функцію в ланцюжку викликів. Цей підхід захаращує код, створює тісний зв'язок між модулями та перетворює підтримку на постійний кошмар.
Зустрічайте асинхронний контекст — концепцію, яка пропонує надійне вирішення цієї давньої проблеми. З появою стабільного AsyncLocalStorage API у Node.js розробники отримали потужний вбудований механізм для елегантного та ефективного управління змінними в межах запиту. Цей посібник проведе вас через всебічну подорож світом асинхронного контексту JavaScript, пояснюючи проблему, представляючи рішення та надаючи практичні, реальні приклади, які допоможуть вам створювати більш масштабовані, підтримувані та спостережувані додатки для глобальної бази користувачів.
Основний виклик: Стан у конкурентному, асинхронному світі
Щоб повною мірою оцінити рішення, ми повинні спочатку зрозуміти глибину проблеми. Сервер Node.js обробляє тисячі одночасних запитів. Коли надходить Запит А, Node.js може почати його обробку, а потім призупинитися, щоб дочекатися завершення запиту до бази даних. Поки він чекає, він береться за Запит Б і починає працювати над ним. Щойно результат з бази даних для Запиту А повертається, Node.js відновлює його виконання. Це постійне перемикання контексту є магією його продуктивності, але воно руйнує традиційні методи управління станом.
Чому глобальні змінні не працюють
Першим інстинктом розробника-початківця може бути використання глобальної змінної. Наприклад:
let currentUser; // Глобальна змінна
// Middleware для встановлення користувача
app.use((req, res, next) => {
currentUser = await getUserFromDb(req.headers.authorization);
next();
});
// Сервісна функція глибоко в додатку
function logActivity() {
console.log(`Activity for user: ${currentUser.id}`);
}
Це катастрофічна помилка проєктування в конкурентному середовищі. Якщо Запит А встановлює currentUser, а потім очікує на асинхронну операцію, може надійти Запит Б і перезаписати currentUser до завершення Запиту А. Коли Запит А відновить роботу, він некоректно використає дані із Запиту Б. Це створює непередбачувані помилки, пошкодження даних та вразливості безпеки. Глобальні змінні не є безпечними для запитів.
Біль "прокидання пропсів"
Більш поширеним і безпечним обхідним шляхом було "прокидання пропсів" або "передача параметрів". Це передбачає явну передачу контексту як аргументу кожній функції, яка його потребує.
Уявімо, що нам потрібен унікальний traceId для логування та об'єкт user для авторизації по всьому нашому додатку.
Приклад прокидання пропсів:
// 1. Точка входу: Middleware
app.use((req, res, next) => {
const traceId = generateTraceId();
const user = { id: 'user-123', locale: 'en-GB' };
const requestContext = { traceId, user };
processOrder(requestContext, req.body.orderId);
});
// 2. Рівень бізнес-логіки
function processOrder(context, orderId) {
log('Processing order', context);
const orderDetails = getOrderDetails(context, orderId);
// ... more logic
}
// 3. Рівень доступу до даних
function getOrderDetails(context, orderId) {
log(`Fetching order ${orderId}`, context);
return db.query('SELECT * FROM orders WHERE id = ?', orderId);
}
// 4. Рівень утиліт
function log(message, context) {
console.log(`[${context.traceId}] [User: ${context.user.id}] - ${message}`);
}
Хоча це працює і є безпечним з точки зору проблем конкурентності, воно має значні недоліки:
- Захаращення коду: Об'єкт
contextпередається всюди, навіть через функції, які не використовують його безпосередньо, але повинні передати його далі функціям, які вони викликають. - Тісний зв'язок: Сигнатура кожної функції тепер прив'язана до форми об'єкта
context. Якщо вам потрібно додати нові дані до контексту (наприклад, прапорець A/B тестування), вам, можливо, доведеться змінити десятки сигнатур функцій по всій вашій кодовій базі. - Зниження читабельності: Основна мета функції може бути затьмарена шаблонним кодом для передачі контексту.
- Тягар підтримки: Рефакторинг стає виснажливим і схильним до помилок процесом.
Нам потрібен був кращий спосіб. Спосіб мати "магічний" контейнер, який містить специфічні для запиту дані, доступні з будь-якого місця в межах асинхронного ланцюжка викликів цього запиту, без явної передачі.
Зустрічайте `AsyncLocalStorage`: Сучасне рішення
Клас AsyncLocalStorage, стабільна функція з Node.js v13.10.0, є офіційною відповіддю на цю проблему. Він дозволяє розробникам створювати ізольований контекст зберігання, який зберігається протягом усього ланцюжка асинхронних операцій, ініційованих з певної точки входу.
Ви можете думати про це як про форму "сховища, локального для потоку" (thread-local storage) для асинхронного, керованого подіями світу JavaScript. Коли ви починаєте операцію в межах контексту AsyncLocalStorage, будь-яка функція, викликана з цього моменту — синхронна, на основі колбеків або промісів — може отримати доступ до даних, що зберігаються в цьому контексті.
Основні концепції API
API напрочуд простий і потужний. Він обертається навколо трьох ключових методів:
new AsyncLocalStorage(): Створює новий екземпляр сховища. Зазвичай ви створюєте один екземпляр для кожного типу контексту (наприклад, один для всіх HTTP-запитів) і використовуєте його спільно у вашому додатку.als.run(store, callback): Це робоча конячка. Цей метод запускає функцію (callback) і встановлює новий асинхронний контекст. Перший аргумент,store, — це дані, які ви хочете зробити доступними в межах цього контексту. Будь-який код, виконаний всерединіcallback, включно з асинхронними операціями, матиме доступ до цьогоstore.als.getStore(): Цей метод використовується для отримання даних (store) з поточного контексту. Якщо викликати його поза контекстом, встановленим за допомогоюrun(), він повернеundefined.
Практична реалізація: Покроковий посібник
Давайте проведемо рефакторинг нашого попереднього прикладу з прокиданням пропсів, використовуючи AsyncLocalStorage. Ми будемо використовувати стандартний сервер Express.js, але принцип залишається тим самим для будь-якого фреймворку Node.js або навіть для нативного модуля http.
Крок 1: Створіть центральний екземпляр `AsyncLocalStorage`
Найкращою практикою є створення єдиного, спільного екземпляра вашого сховища та його експорт, щоб його можна було використовувати по всьому додатку. Створимо файл з назвою asyncContext.js.
// asyncContext.js
import { AsyncLocalStorage } from 'async_hooks';
export const requestContextStore = new AsyncLocalStorage();
Крок 2: Встановіть контекст за допомогою Middleware
Ідеальне місце для запуску контексту — на самому початку життєвого циклу запиту. Middleware ідеально для цього підходить. Ми згенеруємо наші специфічні для запиту дані, а потім обернемо решту логіки обробки запиту всередину als.run().
// server.js
import express from 'express';
import { requestContextStore } from './asyncContext.js';
import { v4 as uuidv4 } from 'uuid'; // Для генерації унікального traceId
const app = express();
// Магічний middleware
app.use((req, res, next) => {
const traceId = req.headers['x-request-id'] || uuidv4();
const user = { id: 'user-123', locale: 'en-GB' }; // У реальному додатку це надходить з middleware автентифікації
const store = { traceId, user };
// Встановлюємо контекст для цього запиту
requestContextStore.run(store, () => {
next();
});
});
// ... тут ваші маршрути та інші middleware
У цьому middleware для кожного вхідного запиту ми створюємо об'єкт store, що містить traceId та user. Потім ми викликаємо requestContextStore.run(store, ...). Виклик next() всередині гарантує, що всі наступні middleware та обробники маршрутів для цього конкретного запиту будуть виконуватися в межах цього новоствореного контексту.
Крок 3: Доступ до контексту з будь-якого місця, без прокидання пропсів
Тепер наші інші модулі можна радикально спростити. Їм більше не потрібен параметр context. Вони можуть просто імпортувати наш requestContextStore і викликати getStore().
Рефакторена утиліта логування:
// logger.js
import { requestContextStore } from './asyncContext.js';
export function log(message) {
const context = requestContextStore.getStore();
if (context) {
const { traceId, user } = context;
console.log(`[${traceId}] [User: ${user.id}] - ${message}`);
} else {
// Запасний варіант для логів поза контекстом запиту
console.log(`[NO_CONTEXT] - ${message}`);
}
}
Рефакторені рівні бізнес-логіки та даних:
// orderService.js
import { log } from './logger.js';
import * as db from './database.js';
export function processOrder(orderId) {
log('Processing order'); // Контекст не потрібен!
const orderDetails = getOrderDetails(orderId);
// ... more logic
}
function getOrderDetails(orderId) {
log(`Fetching order ${orderId}`); // Логер автоматично підхопить контекст
return db.query('SELECT * FROM orders WHERE id = ?', orderId);
}
Різниця кардинальна. Код став значно чистішим, більш читабельним і повністю відокремленим від структури контексту. Наша утиліта логування, бізнес-логіка та рівні доступу до даних тепер чисті та зосереджені на своїх конкретних завданнях. Якщо нам колись знадобиться додати нову властивість до нашого контексту запиту, нам потрібно буде змінити лише той middleware, де він створюється. Жодну іншу сигнатуру функції не потрібно буде чіпати.
Розширені випадки використання та глобальна перспектива
Контекст в межах запиту призначений не лише для логування. Він відкриває безліч потужних патернів, необхідних для створення складних, глобальних додатків.
1. Розподілене трасування та спостережуваність
У мікросервісній архітектурі одна дія користувача може викликати ланцюжок запитів через кілька сервісів. Щоб відлагоджувати проблеми, вам потрібно мати можливість відстежити всю цю подорож. AsyncLocalStorage є наріжним каменем сучасного трасування. Вхідному запиту до вашого API-шлюзу може бути присвоєно унікальний traceId. Потім цей ID зберігається в асинхронному контексті та автоматично включається в будь-які вихідні виклики API (наприклад, як HTTP-заголовок) до downstream-сервісів. Кожен сервіс робить те саме, поширюючи контекст. Централізовані платформи логування можуть потім збирати ці логи та реконструювати повний, наскрізний потік запиту по всій вашій системі.
2. Інтернаціоналізація (i18n) та локалізація (l10n)
Для глобального додатка критично важливо представляти дати, час, числа та валюти у локальному форматі користувача. Ви можете зберігати локаль користувача (наприклад, 'fr-FR', 'ja-JP', 'en-US') з його заголовків запиту або профілю користувача в асинхронному контексті.
// Утиліта для форматування валюти
import { requestContextStore } from './asyncContext.js';
function formatCurrency(amount, currencyCode) {
const context = requestContextStore.getStore();
const locale = context?.user?.locale || 'en-US'; // Запасний варіант за замовчуванням
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).format(amount);
}
// Використання глибоко в додатку
const priceString = formatCurrency(199.99, 'EUR'); // Автоматично використовує локаль користувача
Це забезпечує послідовний користувацький досвід без необхідності передавати змінну locale всюди.
3. Управління транзакціями бази даних
Коли один запит повинен виконати кілька записів до бази даних, які мають або всі успішно завершитися, або всі скасуватися, вам потрібна транзакція. Ви можете розпочати транзакцію на початку обробника запиту, зберегти клієнт транзакції в асинхронному контексті, і тоді всі наступні виклики до бази даних у межах цього запиту автоматично використовуватимуть той самий клієнт транзакції. Наприкінці обробника ви можете підтвердити або відкотити транзакцію залежно від результату.
4. Перемикання функцій та A/B тестування
Ви можете визначити, до яких функціональних прапорців або груп A/B-тестування належить користувач на початку запиту, і зберегти цю інформацію в контексті. Різні частини вашого додатка, від рівня API до рівня рендерингу, можуть звертатися до контексту, щоб вирішити, яку версію функції виконати або який інтерфейс відобразити, створюючи персоналізований досвід без складної передачі параметрів.
Міркування щодо продуктивності та найкращі практики
Поширене питання: які накладні витрати на продуктивність? Команда ядра Node.js доклала значних зусиль, щоб зробити AsyncLocalStorage високоефективним. Він побудований на основі API async_hooks на рівні C++ і глибоко інтегрований з рушієм JavaScript V8. Для переважної більшості веб-додатків вплив на продуктивність є незначним і значно переважується величезними перевагами в якості коду та можливості його підтримки.
Щоб ефективно його використовувати, дотримуйтесь цих найкращих практик:
- Використовуйте єдиний екземпляр (Singleton): Як показано в нашому прикладі, створіть єдиний експортований екземпляр
AsyncLocalStorageдля вашого контексту запиту, щоб забезпечити узгодженість. - Встановлюйте контекст у точці входу: Завжди використовуйте middleware верхнього рівня або початок обробника запиту для виклику
als.run(). Це створює чітку та передбачувану межу для вашого контексту. - Вважайте сховище незмінним: Хоча сам об'єкт сховища є змінним, хорошою практикою є вважати його незмінним. Якщо вам потрібно додати дані в середині запиту, часто чистіше створити вкладений контекст за допомогою ще одного виклику
run(), хоча це більш просунутий патерн. - Обробляйте випадки без контексту: Як показано в нашому логері, ваші утиліти повинні завжди перевіряти, чи повертає
getStore()undefined. Це дозволяє їм коректно функціонувати при запуску поза контекстом запиту, наприклад, у фонових скриптах або під час запуску додатка. - Обробка помилок просто працює: Асинхронний контекст коректно поширюється через ланцюжки
Promise, блоки.then()/.catch()/.finally()таasync/awaitзtry/catch. Вам не потрібно робити нічого особливого; якщо виникає помилка, контекст залишається доступним у вашій логіці обробки помилок.
Висновок: Нова ера для додатків на Node.js
AsyncLocalStorage — це більше, ніж просто зручна утиліта; це зміна парадигми в управлінні станом у серверному JavaScript. Він надає чисте, надійне та продуктивне рішення для давньої проблеми управління контекстом в межах запиту у висококонкурентному середовищі.
Прийнявши цей API, ви можете:
- Позбутися прокидання пропсів: Писати чистіші, більш сфокусовані функції.
- Роз'єднати ваші модулі: Зменшити залежності та зробити ваш код легшим для рефакторингу та тестування.
- Покращити спостережуваність: Легко реалізувати потужне розподілене трасування та контекстне логування.
- Створювати складні функції: Спростити складні патерни, такі як управління транзакціями та інтернаціоналізація.
Для розробників, що створюють сучасні, масштабовані та глобально-орієнтовані додатки на Node.js, оволодіння асинхронним контекстом більше не є опціональним — це необхідна навичка. Переходячи від застарілих патернів і впроваджуючи AsyncLocalStorage, ви можете писати код, який є не тільки більш ефективним, але й значно елегантнішим та легшим у підтримці.